﻿using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;

namespace Hibiku.Twitter
{
    public sealed class TwitterApi : ITwitterApi
    {
        public event Action<EStream, IEnumerable<Tweet>> TweetsReceived;

        public event EventHandler Unauthorized;

        public event EventHandler Authorized;

        private static readonly string UserAgent = "Hibiku/" + typeof(TwitterApi).Assembly.GetName().Version.ToString();

        private static readonly string BaseUrl = "http://api.twitter.com";
        private static readonly string Version = "1";

        // Todo: proper configuration
        private static readonly int NumTweetsToRequest = 50;


        public AuthorizationResponse Authorization
        {
            get;
            private set;
        }

        public TwitterApi(AuthorizationResponse authorizationResponse)
        {
            Authorization = authorizationResponse;

            ServicePointManager.Expect100Continue = false;
        }

        public TwitterApi()
            : this(null)
        {
        }

        #region ITwitterApi members

        public bool IsAuthorized
        {
            get
            {
                return Authorization != null && Authorization.IsValid;
            }
        }

        public AuthorizationRequest GetAuthorizationRequest()
        {
            var pair = GetRequestToken();

            var url = string.Format("{0}/oauth/authorize?oauth_token={1}", BaseUrl, pair.Token);

            return new AuthorizationRequest(url, pair);
        }

        public void Authorize(string pin, TokenPair requestTokenPair)
        {
            var oauthHeader = MakeOAuthHeader("POST", "/oauth/access_token", requestTokenPair.Secret, new Param("oauth_verifier", pin), new Param("oauth_token", requestTokenPair.Token));
            var text = SendOAuthRequest("POST", "/oauth/access_token", oauthHeader);
            var dict = ParseQueryString(text);
            Authorization = new AuthorizationResponse(dict["user_id"], dict["screen_name"], new TokenPair(dict["oauth_token"], dict["oauth_token_secret"]));

            FireAuthorized();
        }

        public void GetTweets(EStream stream, long? sinceId)
        {
            var path = ToPath(stream);

            AssertCanAuthenticate();

            var params_ = new List<Param>()
            {
                new Param("count", NumTweetsToRequest.ToString(), Param.EParamType.Query)
            };

            if (sinceId.HasValue)
            {
                params_.Add(new Param("since_id", sinceId.Value.ToString(), Param.EParamType.Query));
            }

            try
            {
                var response = SendRequest("GET", path, EResponseFormat.Xml, true, params_.ToArray());

                var xml = XElement.Parse(response);

                var tweets = from status in xml.Descendants(XName.Get("status"))
                             select ExtractTweetFromStatusNode(status);

                FireTweetsReceived(stream, tweets);
            }
            catch (AuthorizationException)
            {
                FireUnauthorized();
            }
        }

        private string ToPath(EStream stream)
        {
            if (stream == EStream.Mentions)
            {
                return "statuses/mentions";
            }
            else if (stream == EStream.HomeTimeline)
            {
                return "statuses/home_timeline";
            }
            else
            {
                throw new ArgumentException(stream.ToString());
            }
        }

        public void Update(string text, long? inReplyToStatusId, Location location)
        {
            AssertCanAuthenticate();

            var params_ = new List<Param>()
            {
                new Param("status", TextCodec.Encode(text), Param.EParamType.Query)
            };

            if (inReplyToStatusId.HasValue)
            {
                params_.Add(new Param("in_reply_to_status_id", inReplyToStatusId.Value.ToString(), Param.EParamType.Query));
            }

            if (location != null)
            {
                params_.Add(new Param("lat",  location.Latitude.ToString(), Param.EParamType.Query));
                params_.Add(new Param("long", location.Longitude.ToString(), Param.EParamType.Query));
                params_.Add(new Param("display_coordinates", "true", Param.EParamType.Query));                
            }

            try
            {
                var response = SendRequest("POST", "statuses/update", EResponseFormat.Xml, true, params_.ToArray());

                var xml = XElement.Parse(response);

                var tweet = ExtractTweetFromStatusNode(xml);

                FireTweetsReceived(EStream.HomeTimeline, tweet);
            }
            catch (AuthorizationException)
            {
                FireUnauthorized();
            }
        }

        #endregion

        private void FireAuthorized()
        {
            var handlers = Authorized;

            if (handlers != null)
            {
                handlers(this, EventArgs.Empty);
            }
        }

        private void FireUnauthorized()
        {
            var handlers = Unauthorized;

            if (handlers != null)
            {
                handlers(this, EventArgs.Empty);
            }
        }

        private void FireTweetsReceived(EStream stream, Tweet tweet)
        {
            FireTweetsReceived(stream, new Tweet[] { tweet });
        }

        private void FireTweetsReceived(EStream stream, IEnumerable<Tweet> tweets)
        {
            var handlers = TweetsReceived;

            if (handlers != null)
            {
                handlers(stream, tweets);
            }
        }

        private Tweet ExtractTweetFromStatusNode(XElement statusNode)
        {
            var retweetedStatusNode = statusNode.Element(XName.Get("retweeted_status"));
            var userNode = statusNode.Element(XName.Get("user"));
            var user = ExtractUserFromNode(userNode);

            Tweet retweeted = null;
            if (retweetedStatusNode != null)
            {
                retweeted = ExtractTweetFromStatusNode(retweetedStatusNode);
            }

            var id = (Convert.ToInt64(statusNode.Element(XName.Get("id")).Value));
            var text = statusNode.Element(XName.Get("text")).Value;
            var location = ExtractLocation(statusNode);
            var createdAt = DateTime.ParseExact(statusNode.Element(XName.Get("created_at")).Value, "ddd MMM dd HH:mm:ss zzzz yyyy", System.Globalization.CultureInfo.InvariantCulture);

            try
            {
                return new Tweet(user, id, createdAt, TextCodec.Decode(text), location, retweeted);
            }
            catch (Exception)
            {
                return null;
            }
        }

        private Location ExtractLocation(XElement statusNode)
        {
            var coordinates = statusNode.Element("coordinates").Value;
            var parts = coordinates.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);

            if (parts.Length != 2)
            {
                return null;
            }

            var latitude = Convert.ToDouble(parts[0], CultureInfo.InvariantCulture);
            var longitude = Convert.ToDouble(parts[1], CultureInfo.InvariantCulture);

            if (string.IsNullOrEmpty(coordinates))
            {
                return null;
            }
            else
            {
                Place place = null;
                var placeNode = statusNode.Element(XName.Get("place"));
                if (placeNode.HasElements)
                {
                    var id = placeNode.Element(XName.Get("id")).Value;
                    var name = placeNode.Element(XName.Get("name")).Value;
                    var fullName = placeNode.Element(XName.Get("full_name")).Value;
                    var placeType = placeNode.Element(XName.Get("place_type")).Value;
                    var url = placeNode.Element(XName.Get("url")).Value;
                    var country = placeNode.Element(XName.Get("country")).Value;

                    place = new Place(id, name, fullName, placeType, url, country);
                }

                return new Location(latitude, longitude, place);
            }
        }

        private User ExtractUserFromNode(XElement userNode)
        {
            var id = Convert.ToInt64(userNode.Element(XName.Get("id")).Value);
            var name = userNode.Element(XName.Get("name")).Value;
            var screenName = userNode.Element(XName.Get("screen_name")).Value;
            var location = userNode.Element(XName.Get("location")).Value;
            var description = userNode.Element(XName.Get("description")).Value;
            var url = userNode.Element(XName.Get("url")).Value;
            var profileImageUrl = userNode.Element(XName.Get("profile_image_url")).Value;

            return new User(id, name, screenName, location, description, url, profileImageUrl);
        }

        private string SendRequest(string method, string path, EResponseFormat format, bool isOauth, params Param[] args)
        {
            var query = args.ToQueryString();
            var queryString = (string.IsNullOrEmpty(query) ? "" : "?" + query);
            var fullPath = string.Format("/{0}/{1}.{2}", Version, path, format.ToString().ToLower());
            var url = string.Format("{0}{1}{2}", BaseUrl, fullPath, queryString);
            try
            {
                var request = WebRequest.Create(url) as HttpWebRequest;
                request.Method = method;
                request.UserAgent = UserAgent;
                request.Accept = ResponseFormatMimeRegistry.GetMimeType(format);

                if (isOauth)
                {
                    var params_ = new List<Param>(args);
                    params_.Add(new Param("oauth_token", Authorization.AccessTokenPair.Token));
                    var oauthHeader = MakeOAuthHeader(method, fullPath, Authorization.AccessTokenPair.Secret, params_.ToArray());
                    request.Headers[HttpRequestHeader.Authorization] = oauthHeader;
                }

                var response = request.GetResponse();
                using (var stream = response.GetResponseStream())
                {
                    using (var reader = new StreamReader(stream))
                    {
                        return reader.ReadToEnd();
                    }
                }
            }
            catch (WebException ex)
            {
                if (ex.Status == WebExceptionStatus.ProtocolError)
                {
                    Authorization = null;

                    throw new AuthorizationException();
                }
                else
                {
                    throw;
                }
            }
        }

        private string SendOAuthRequest(string method, string path, string oauthHeader)
        {
            var url = string.Format("{0}{1}", BaseUrl, path);
            try
            {
                var request = WebRequest.Create(url) as HttpWebRequest;
                request.Method = method;
                request.UserAgent = UserAgent;
                request.Headers[HttpRequestHeader.Authorization] = oauthHeader;
                var response = request.GetResponse();
                using (var stream = response.GetResponseStream())
                {
                    using (var reader = new StreamReader(stream))
                    {
                        return reader.ReadToEnd();
                    }
                }
            }
            catch (Exception ex)
            {
                throw new ApplicationException("OAuth request failed", ex);
            }
        }


        private TokenPair GetRequestToken()
        {
            var oauthHeader = MakeOAuthHeader("POST", "/oauth/request_token", "", new Param("oauth_callback", "oob"));
            var text = SendOAuthRequest("POST", "/oauth/request_token", oauthHeader);

            var dict = ParseQueryString(text);
            return new TokenPair(dict["oauth_token"], dict["oauth_token_secret"]);
        }


        private string MakeOAuthHeader(string method, string path, string tokenSecret, params Param[] args)
        {
            var params_ = new List<Param>() {
                new Param("oauth_consumer_key", Constants.ConsumerKey),
                new Param("oauth_signature_method", "HMAC-SHA1"),
                new Param("oauth_nonce", MakeNonce()),
                new Param("oauth_timestamp", MakeTimestamp()),
                new Param("oauth_version", "1.0")
            };

            params_.AddRange(args);

            var baseString = params_.ToOAuthBaseString(method, BaseUrl + path);

            var signature = Sign(baseString, tokenSecret);

            params_.Add(new Param("oauth_signature", signature));

            return params_.ToOAuthValue();
        }

        private void AssertCanAuthenticate()
        {
            if (!IsAuthorized)
            {
                throw new InvalidOperationException("Authentication required");
            }
        }

        #region OAuth

        private string Sign(string baseString, string tokenSecret)
        {
            var key = Encoding.UTF8.GetBytes(Constants.ConsumerSecret + "&" + tokenSecret);
            var signer = new HMACSHA1(key);
            var hashBytes = signer.ComputeHash(Encoding.UTF8.GetBytes(baseString));

            return Convert.ToBase64String(hashBytes);
        }

        private string MakeNonce()
        {
            return Guid.NewGuid().ToString().Replace("-", "");
        }

        private string MakeTimestamp()
        {
            var timeSpan = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, 0);
            var seconds = Convert.ToInt64(timeSpan.TotalSeconds);
            return seconds.ToString();
        }

        #endregion

        #region Helpers

        private IDictionary<string, string> ParseQueryString(string query)
        {
            var pairs = query.Split('&');

            return pairs.Select(x => x.Split('=')).ToDictionary(x => x[0], y => y[1]);
        }

        #endregion
    }
}
